Skip to content

fix(web): an expired proxy session asks for a reload instead of reconnecting forever - #984

Merged
xintaofei merged 1 commit into
spacering-net:mainfrom
MagTer:fix/proxy-session-expiry
Oct 20, 2026
Merged

xintaofei merged 1 commit into
spacering-net:mainfrom
MagTer:fix/proxy-session-expiry

Conversation

@MagTer

@MagTer MagTer commented Oct 19, 2026

Copy link
Copy Markdown

Part 1 of #981 — the expired-session half. The opt-in proxy-JWT mode follows in a separate PR that builds on this one.

Problem

Behind an identity-aware proxy (Cloudflare Access here), once the proxy's session expires it answers the app's fetch('/api/…') with a 302 to its login page on another origin. fetch cannot follow that, probeHealth sees a network error, and the reconnect dialog spins forever; only a manual reload gets out.

Change

  • X-Requested-With: XMLHttpRequest on every API call — WebTransport.call, the health probe, the multipart and XHR uploads, the root page and the login page — through one helper, codegAuthHeaders() in web-auth.ts. Cloudflare Access answers such a request with 401 instead of the redirect. Measured against a live Access application: POST /api/health without a session returns 302 → https://<team>.cloudflareaccess.com/cdn-cgi/access/login/…; the same request with the header returns 401 with WWW-Authenticate: Cloudflare-Access ….
  • Codeg's own 401 now carries WWW-Authenticate: Bearer realm="codeg" (web/auth.rs). A 401 alone would otherwise send the user to the token login, whose "Go to login" wipes a token that is still valid. The client reads the challenge (unauthorizedSource()): Codeg's 401 keeps today's Session expired / Go to login; any other 401 shows a new Sign-in expired state with Reload (primary) and Go to login (secondary, the way out if the proxy keeps refusing), and keeps the token. A reload is a top-level navigation, which the service worker passes to the network (navigateNetworkFirst), so the proxy gets to run its login.
  • The root page no longer clears the token on a proxy's 401, and the login page no longer calls the token invalid for one.
  • New strings WebConnection.proxySignInTitle / proxySignInDescription in all ten locales (reloadPage is reused). The non-English ones are my translations; corrections welcome.

Tests

  • New and changed frontend tests: web-auth.test.ts (new), web-transport.test.ts, web-connection-guard.test.tsx, src/app/page.test.tsx (new), src/app/login/page.test.tsx (new). All 19 new cases were run against the unchanged code first and failed. One existing helper changed: the transport tests' resp401 now carries Codeg's challenge, because that is what the server sends now.
  • Rust: every_rejection_carries_the_codeg_challenge and an_accepted_request_carries_no_challenge in web/auth.rs; the first was seen failing with the header removed.
  • Run locally: pnpm lint ., tsc --noEmit, pnpm test (all green; once, sidebar-tag-filter.test.tsx "keeps → for the search box in a right-to-left menu" failed under full-suite load and passed 3/3 in isolation, with and without this change), pnpm build, browser:agent:check/types; cargo test + clippy -D warnings for the server and cli cells.
  • Not run locally: the desktop cells (no webkit dev libraries on my machine). The Rust change is in web/auth.rs, which is not cfg-gated, so the server and cli cells compile the same code, but the desktop build itself is CI's to confirm.
  • End to end in a real browser (Playwright, Chromium): the built codeg-server behind a small fake proxy that behaves like Access (passes through until its session "expires", then 401 for X-Requested-With API calls and a cross-origin 302 for everything else). Logged in with the token, expired the proxy session and dropped the sockets: the app showed Sign-in expired with Reload, the token stayed in localStorage, Reload went to the proxy's login, and after "signing in" again the workspace opened without the token prompt. The same script against main's frontend never reaches Sign-in expired.

Against real Cloudflare Access (this branch plus part 2, built from source and run behind a live Access application): with the app open, I revoked my Access session in Zero Trust and the app showed Sign-in expired with Reload instead of the reconnect loop. Reload then landed on Cloudflare's own "revoked" page rather than the login, and getting back in took some fiddling on the Cloudflare side. As far as I can tell, that is how Access treats a revoked session: the app's cookie stays blocked, whereas a session that simply expires answers with a redirect to the login (the measurement above). Reload after a natural expiry has not been run against this build yet.

Closes part 1 of #981.

🤖 Generated with Claude Code

…necting forever

Behind an identity-aware proxy (Cloudflare Access in the reporter's setup),
an expired proxy session made the API fetch receive a 302 to the proxy's
login page on another origin. fetch cannot follow that, so the health probe
saw a network error and the reconnect dialog never resolved; only a manual
reload got out (spacering-net#981).

API calls, health probes and uploads now send X-Requested-With:
XMLHttpRequest, which Cloudflare Access answers with a 401 instead of the
redirect (measured against a live Access application: 302 without the
header, 401 with it).

A 401 alone would then bounce the user to the token login and wipe a token
that is still valid. Codeg's own 401 now carries WWW-Authenticate:
Bearer realm="codeg", and the client reads that challenge to tell the two
apart: Codeg's 401 keeps today's "Session expired" / "Go to login", any
other 401 shows "Sign-in expired" with Reload (the proxy then runs its own
login flow; the service worker passes navigations through, so a reload
reaches it) and keeps the token. "Go to login" stays in that dialog as the
way out if the proxy keeps refusing. The root page no longer clears the
token on a proxy's 401, and the login page no longer calls the token
invalid for one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@xintaofei

Copy link
Copy Markdown
Collaborator

@MagTer thanks, this one is ready from our side, with no changes.

What I checked:

  • Token mode. A proxy's 401 no longer clears the stored token or calls it invalid; only "Go to login" clears it, as before. The auth middleware now marks every 401 it returns with the Bearer realm="codeg" challenge, which is what the web transport uses to tell a token rejection from a proxy's 401. The public routes that answer 401 themselves, the Office live-preview proxy and the forge webhook, are never called by the transport. A failed sign-in inside a command, such as a wrong backup passphrase, is a 422.
  • The header. Cloudflare documents X-Requested-With: XMLHttpRequest for exactly this case: with it, an expired session gets a 401 instead of a redirect, and the app is expected to reload or tell the user (Session management).
  • Reload. The service worker passes navigations to the network and returns the response as it is, so the cross-origin redirect to the login page reaches the browser.

On the Reload-after-revoke gap you mentioned: the same Cloudflare page says that after a revoke, signing back in is blocked for up to a minute with an error, which matches what you saw. A natural expiry should take the normal login redirect instead, as the 302 you measured in #981 shows. If you get to try one on your setup, a note here is welcome, but it doesn't hold this PR.

CI is green. The one red job in the first run was the sidebar-tag-filter timing flake you mention in the PR description, and a rerun passed. One wording detail is handled in #985 instead of here: on the login page, "your access token is kept" shows before any token has been stored, so #985 drops that clause.

@xintaofei
xintaofei merged commit 8e85ef8 into spacering-net:main Oct 20, 2026
25 of 26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants